/**
 * Created by: Simon McLeish
 * Date: 23-Jun-2010
 * Time: 12:47:34
 * Copyright: London School of Economics 2010
 * Release: 0.1
 */
 
/**
 * This file is part of the Library Locator.

 * The Library Locator is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * The Library Locator is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with the Library Locator.  If not, see <http://www.gnu.org/licenses/>.
 */

/**
 * This is the main (sole) class for the map display. It is a servlet which overlays a pointer graphic (rotated if required) on
 * one of a set of map graphics and displays the resulting graphic as a JPEG.
 *
 * Configuration parameters (see web.xml):
 *      mainFileDirectory: directory containing map files
 *      pointerFileName: graphics file to use as a pointer
 *      pointerHotSpot: co-ordinates of pixel within the pointer to use as the origin for rotation
 *      pointerErrorResolutionProcess: what to do if the computed position of the pointer overlaps but does not completely
 *          lie within the map graphic; currently the only option is to return an error
 *      metadataFile: XML format file which contains information about the map graphics, including the real world latitude/
 *          longitude of file corners to enable the location and bearing to use real world co-ordinates
 *
 * Parameters passed in calling URL:
 *      mapfilePrefix: this is the final part of the PATH used to call the servlet, and indicates a group of map files to
 *          use (intended for a choice between several different versions mapping the same location, for example to support
 *          multiple resolutions
 *      location: where to place the hot spot of the pointer (in real world latitude,longitude form)
 *      bearing: angle in degrees from north through which to rotate the pointer file
 *
 * Important note: all the calculations relating pixels in the graphic to real world co-ordinates assume that the earth is
 * flat! This should be a perfectly valid assumption (to the limit of accuracy of the calculations) for any map of a building
 * sized object.
 *
 */

package uk.ac.lse.libraryLocator.mapDisplay;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import javax.imageio.ImageIO;
import javax.media.jai.JAI;
import javax.media.jai.PlanarImage;
import javax.media.jai.RenderedOp;
import javax.media.jai.TiledImage;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.Hashtable;

public class GenerateMap extends HttpServlet {
  // initialised constants from configuration
  public Integer logLevel;
  public String mainFileDirName, pointerFileName, pointerErrorResolutionProcess;
  public Integer pointerHotSpotx, pointerHotSpoty;
  public Hashtable<String,Hashtable<String,Float[]>> fileMetadataHash = new Hashtable<String,Hashtable<String,Float[]>>();

  public void init() throws javax.servlet.ServletException {
    // Initialisation method for servlet
    
    // Get config information
    String logLevelStr = getServletConfig().getInitParameter("logLevel");

    // Note: this logs to tomcat log file via private enterLog method; should replace with more flexible mechanism later
    logLevel = 3; // default value: "INFO"
    if (logLevelStr.equals("NONE")) {logLevel = 0;}
    else if (logLevelStr.equals("ERROR")) {logLevel = 1;}
    else if (logLevelStr.equals("WARN")) {logLevel = 2;}
    else if (logLevelStr.equals("DEBUG")) {logLevel = 4;}
	
    enterLog("Initialising", 4);

    mainFileDirName = getServletConfig().getInitParameter("mainFileDirectory");
    enterLog("Main file directory: " + mainFileDirName, 4);
        
    pointerFileName = getServletConfig().getInitParameter("pointerFileName");
    enterLog("Pointer file name: " + pointerFileName, 4);
    if (pointerFileName == null || "".equals(pointerFileName)){
      enterLog("Configuration gives a null filename for the pointer",1);
      throw new ServletException("Configuration gives a null filename for the pointer");
    }

    String pointerHotSpot = getServletConfig().getInitParameter("pointerHotSpot");
    int pos = pointerHotSpot.indexOf(',');
    // pos needs to be not -1 (not found), 0 (first character), or value of length() (last character)
    if (pos > 0 && pos < pointerHotSpot.length()){
      pointerHotSpotx = Integer.parseInt(pointerHotSpot.substring(0,pos));
      pointerHotSpoty = Integer.parseInt(pointerHotSpot.substring(pos+1));
      enterLog("Pointer hot spot: " + pointerHotSpotx + "," + pointerHotSpoty,4);
    } else {
      enterLog("Configuration doesn't describe pointer hot spot location correctly",1);
      throw new ServletException("Configuration doesn't describe pointer hot spot location correctly: value is " + pointerHotSpot);
    }

    pointerErrorResolutionProcess = "error"; // until we implement something more complicated, ignore configured value!
    enterLog("Pointer Error Resolution Process: " + pointerErrorResolutionProcess,4);
    
    // load in graphic file metadata and make available - use DOM to parse it because it's a small and simple file
    String metadataFileName = getServletConfig().getInitParameter("mapMetadataFile");
    if (metadataFileName != null && ! "".equals(metadataFileName)){
      File metadataFile = new File(metadataFileName);
      if (metadataFile.exists() && metadataFile.canRead()){
	DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
	Document doc;
	try {
	  DocumentBuilder db = dbf.newDocumentBuilder();
	  doc = db.parse(metadataFile);
      
	  doc.getDocumentElement().normalize();
	  enterLog("Processing metadata file; Root element " + doc.getDocumentElement().getNodeName(), 4);
	  NodeList mapNodeList = doc.getElementsByTagName("map");
	  if (mapNodeList.getLength() > 0){
	    enterLog("Processing map metadata file information",4);
	    for (int i = 0; i < mapNodeList.getLength(); i++) {
	      enterLog("Processing map node " + i,4);
	      Element mapElt = (Element) mapNodeList.item(i); // we're assuming it's an Element, because the schema enforces this
	      String fileNameFromAttr = mapElt.getAttribute("fileName"); // exists by schema
	      NodeList pointNodeList = mapElt.getElementsByTagName("point"); // pointElementList.getLength == 2 by schema
	      Hashtable<String,Float[]> pointHash = new Hashtable<String,Float[]>();
	      for (int j = 0; j <2; j++){
		Element pointElt = (Element) pointNodeList.item(j);
		String pointLocation = pointElt.getAttribute("location");
		Float[] latlong = new Float[2];
		latlong[0] = Float.parseFloat(pointElt.getAttribute("latitude"));
		latlong[1] = Float.parseFloat(pointElt.getAttribute("longitude"));
		pointHash.put(pointLocation,latlong);
	      }
	      enterLog("Stored metadata about file " + fileNameFromAttr, 4);
	      fileMetadataHash.put(fileNameFromAttr,pointHash);
	    }
	  } else {
	    enterLog("No map file information in map metadata file",1);
	    throw new ServletException("No map file information in map metadata file");
	  }
	} catch (javax.xml.parsers.ParserConfigurationException e){
	  enterLog("Error in parser configuration for metadata document:" + e.getMessage(),1);
	  throw new ServletException("Error in parser configuration for metadata document:" + e.getMessage());
	} catch (SAXException e) {
	    enterLog("Error parsing metadata document:" + e.getMessage(),1);
	    throw new ServletException("Error parsing metadata document:" + e.getMessage());
	} catch (IOException e) {
	    enterLog("Error opening metadata document:" + e.getMessage(),1);
	    throw new ServletException("Error opening metadata document:" + e.getMessage());
	}
      } else {
	enterLog("Cannot load metadata file "+metadataFileName,1);
	throw new ServletException("Cannot load metadata file "+metadataFileName);
      }
    } else {
      enterLog("Configuration error: metadata file name is null",1);
      throw new ServletException("Configuration error: metadata file name is null");
    }
    
    enterLog("Initialisation completed successfully",3);
  }

  public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException {
    // main servlet method - finds right graphics and superimposes them
	
    // Get info from calling URL - throw error if URL isn't correct or complete enough
    String path = request.getPathInfo();
    String mapNamePrefix = "";
    int slpos = path.lastIndexOf("/");
    if (slpos > -1){
      mapNamePrefix = path.substring(slpos + 1);
    } else {
      mapNamePrefix = path;
    }
    
    if (mapNamePrefix == null || "".equals(mapNamePrefix)){
      enterLog("Error: no map name has been detected",1);
      try {
	response.sendError(404,"Error: no map name has been detected");
      } catch (IOException e) {
	enterLog("Unable to output 404 error: " + e.getMessage(),1);
	throw new ServletException("Unable to output 404 error: " + e.getMessage());
      }
    }
    enterLog("Map group name from URL path: " + mapNamePrefix,4);

    // try for a file with the name mapFilePrefix-resolution.extension: if this doesn't exist, find the first file which starts mapFilePrefix; 
    // if this doesn't exist, return an error
    
    File mapFile = null;
    enterLog("Searching file directory " + mainFileDirName,4);
    
    File mainDir = new File(mainFileDirName);
    

    if (mainDir.exists() && mainDir.canRead()){    
      enterLog("Searching for corresponding map file",4);
      
      String resolution = request.getParameter("resolution");
      if (resolution != null && ! "".equals(resolution)){
        mapNamePrefix = mapNamePrefix + "-" + resolution;
      }
      // if no resolution set, find the first file which starts with the map name given - so no need for an else statetment
      
      final String finalMapNamePrefix = mapNamePrefix; // accept only works with a finalised variable
    
      String[] mapFileList = mainDir.list(new FilenameFilter() {
      // get all files starting with mapNamePrefix
        public boolean accept(File d, String name) { return name.startsWith(finalMapNamePrefix);}
      });
      
      if (mapFileList.length > 0){
        // there is at least one matching file! Now find the one with the right resolution
        // create a new file object using the first matching name from the list
        mapFile = new File(mainFileDirName,mapFileList[0]);
        enterLog("Using map file " + mapFile.getName(),4);
      } else {
        enterLog("No map file found which corresponds to request for " + finalMapNamePrefix,1);
        try {
	  response.sendError(404,"No map file found which corresponds to request for " + finalMapNamePrefix);
	} catch (IOException e) {
	  enterLog("Unable to output 404 error: " + e.getMessage(),1);
	  throw new ServletException("Unable to output 404 error: " + e.getMessage());
	}
      }
      
      enterLog("Loading pointer file " + pointerFileName,4);
      File pointerFile = new File (pointerFileName);
      PlanarImage pointerImage;
      if (pointerFile.exists() && pointerFile.canRead()){
        // load pointer image - error if unable to do this
        pointerImage = JAI.create("fileload",pointerFile.getPath());
      } else {
        // configuration error - pointer file cannot be read
        enterLog("Cannot load pointer file " + pointerFile.getName(),1);
        // exit
        throw new ServletException("Cannot load pointer file " + pointerFileName);
      }
                
      Integer pointerWidth = pointerImage.getWidth();
      Integer pointerHeight = pointerImage.getHeight();

      // now have file name of map file - load main image:  GIF, JPEG, PNG, BMP and WBMP,TIFF files
      // supported
      enterLog("Loading main image file " + mapFile.getPath(),4);
      RenderedImage mainImage = JAI.create("fileload",mapFile.getPath());

      Integer mapWidth = mainImage.getWidth();
      Integer mapHeight = mainImage.getHeight();

      // Load in metadata describing the file
      enterLog("Loading image metadata",4);
      Hashtable<String,Float[]> mapFileMetadataHash = fileMetadataHash.get(mapFile.getName());

      Integer bearingDeg = 0; // default is not to rotate pointer from north!
      String bearingPar = request.getParameter("bearing");

      if ((bearingPar != null) && ! "".equals(bearingPar)) {
        bearingDeg =  Integer.parseInt(bearingPar)%360;
        // mod 360 as number of degrees - also allows anti-clockwise rotation to be expressed as -n!
      }
      enterLog("Bearing is " + bearingDeg, 4);

      // now calculate orientation (in radians, with respect to vertical axis of map) from map file metadata
      double orientation = getOrientation(bearingDeg,mapFileMetadataHash);

      String locationStr = request.getParameter("location");
      if ((locationStr != null) && ! "".equals(locationStr)) {
        // get x and y co-ordinates
        float locationLongitude, locationLatitude;
        int pos = locationStr.indexOf(",");
        if (pos > 0 ){
            locationLongitude = Float.parseFloat(locationStr.substring(0,pos));
            locationLatitude = Float.parseFloat(locationStr.substring(pos + 1));
          } else {
	        // error - location indicated does not make sense (e.g. is not a pair of numbers)
            enterLog("Location entered does not make sense: " + locationStr,1);
      // exit
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      return;
    }

    // obtain x,y co-ordinates in map file from location information
    float[] location = getCoordsFromLatLong(locationLongitude,locationLatitude,mapFileMetadataHash,mapWidth,mapHeight);

    if (location[0] < 0 || location[0] > mapWidth || location[1] < 0 || location[1] > mapHeight){
      // location defined but will put pointer outside map completely - so return image with no pointer
      enterLog("Specified location puts pointer beyond edge of map - returning map with no pointer shown",2);
      response.setContentType("image/jpeg");
      try {
        ImageIO.write(mainImage, "jpg", response.getOutputStream());
      } catch (IOException e) {
        enterLog("Unable to output final image: " + e.getMessage(),1);
        throw new ServletException("Unable to output final image: " + e.getMessage());
      }
      return;
    }

	// add main log entry to record request
    enterLog("Request for map file " + mapFile.getName() + ", orientation " + orientation + "(radians), location " + locationLongitude + "," + locationLatitude, 3);

    // do a bit of trig to see if pointer with given orientation will fit over main image
    enterLog("Finding edges of rotated pointer - see if it will fit on map",4);
    double cosOrient = Math.cos(orientation); // calculate once!
    double sinOrient = Math.sin(orientation); // calculate once!

    // this compares the map image edge to what was originally the bottom right corner of the pointer image

    // pointerA, pointerB, pointerC and pointerD are the coordinates of the four corners of the pointer
    // graphic relative to the location of the bottom left hand corner of the map graphic (starting 
    // from bottom left and working clockwise)

    double[] pointerA = {location[0] - cosOrient * pointerHotSpotx - sinOrient * pointerHotSpoty, location[1] + sinOrient * pointerHotSpotx - cosOrient * pointerHotSpoty};
    double[] pointerB = {pointerA[0] + sinOrient * pointerHeight, pointerA[1] + cosOrient * pointerHeight};
    double[] pointerC = {pointerB[0] + cosOrient * pointerWidth, pointerB[1] - sinOrient * pointerWidth};
    double[] pointerD = {pointerC[0] - sinOrient * pointerHeight, pointerC[1] - cosOrient  * pointerHeight};

    // if any of these points lie off the map, we're in trouble
    enterLog("PointerA: " + pointerA[0] + ", " + pointerA[1],4);
    enterLog("PointerB: " + pointerB[0] + ", " + pointerB[1],4);
    enterLog("PointerC: " + pointerC[0] + ", " + pointerC[1],4);
    enterLog("PointerD: " + pointerD[0] + ", " + pointerD[1],4);

    boolean onMap = true;
    if (pointerA[0] < 0 || pointerA[1] < 0 || pointerB[0] < 0 || pointerB[1] < 0 || pointerC[0] < 0 || pointerC[1] < 0 || pointerD[0] < 0 || pointerD[1] < 0){ onMap = false;}
    if (pointerA[0] > mapWidth || pointerB[0] > mapWidth || pointerC[0] > mapWidth || pointerD[0] > mapWidth){ onMap = false;}
    if (pointerA[1] > mapHeight || pointerB[1] > mapHeight || pointerC[1] > mapHeight || pointerD[1] > mapHeight){ onMap = false;}
          
      // carry out pointer error resolution
      if (! onMap){
	enterLog("Pointer will not fit on map with given orientation",2);
	enterLog("Carrying out pointer error resolution process " + pointerErrorResolutionProcess,2);
	if (pointerErrorResolutionProcess.equals("error")){
	  enterLog("Exiting throwing error as specified by configuration",1);
	  // exit
	  return;
	} else {
	  enterLog("Unsupported pointer error resolution process " + pointerErrorResolutionProcess,1);
	  return;
	}
      }
        // rotate pointer image
        enterLog("Rotating pointer image",4);
	  // angle needs to be Float not double
	  Double orientDouble = new Double(orientation);
	  Float orientFloat = (orientDouble.floatValue());
	RenderedOp op= JAI.create("rotate", pointerImage, (Object) location[0], location[1], orientFloat);
	BufferedImage bufferedPointer = op.getAsBufferedImage();
	enterLog("Pointer image rotation successful",4);
	
	// overlay on main image - requires pointer image manipulation to ensure compatibility
	TiledImage mapTI = new TiledImage(mainImage, false);
        Graphics2D mapG = mapTI.createGraphics();
        mapG.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER));
	
	// need to locate bottom left corner of rotated graphic; where this is will depend on how far round the image is rotated (because different
	// corners of the original graphic will become the lowest and leftmost points)
	
	enterLog("Calculating position of bottom left corner of rotated image - used to position pointer for overlay",4);
	Double[] bottomLeft = new Double[] {0.0,0.0};
	if (0 <= orientation && orientation < (Math.PI / 2)){
          bottomLeft[0] = location[0] - (sinOrient * pointerHotSpotx) + (sinOrient * pointerHotSpoty);
	  bottomLeft[1] = location[1] - (sinOrient * pointerWidth) + (sinOrient * pointerHotSpoty);
	} else if (orientation < Math.PI){
	  bottomLeft[0] = location[0] - (sinOrient * pointerHotSpoty) - (cosOrient * (pointerWidth - pointerHotSpotx));
	  bottomLeft[1] = location[1] - (sinOrient * (pointerWidth - pointerHotSpotx) - (cosOrient * (pointerHeight - pointerHotSpoty)));
	} else if (orientation < (3 * Math.PI / 2)){
	  bottomLeft[0] = location[0] - (cosOrient * (pointerWidth + pointerHeight - pointerHotSpotx - pointerHotSpoty));
	  bottomLeft[1] = location[1] - (sinOrient * (pointerHeight + pointerHotSpotx - pointerHotSpoty));
	} else {
	  bottomLeft[0] = location[0] + sinOrient * (pointerHeight - pointerHotSpoty);
	  bottomLeft[1] = location[1] - cosOrient * pointerHotSpoty;
	}
	
	// create the image
	enterLog("Overlaying image",4);
	mapG.drawImage(bufferedPointer, bottomLeft[0].intValue(), bottomLeft[1].intValue(), null);
	enterLog("Pointer image overlay on map image successful",4);
	
	// return rendered image
	enterLog("Outputting overlaid image",4);
        try {
          ImageIO.write(mapTI, "jpeg", response.getOutputStream());
        } catch (IOException e) {
          enterLog("Unable to output final image: " + e.getMessage(),1);
          throw new ServletException("Unable to output final image: " + e.getMessage());
        }
      } else {
	try {
          ImageIO.write(mainImage, "jpeg", response.getOutputStream());
        } catch (IOException e) {
          enterLog("Unable to output final image: " + e.getMessage(),1);
          throw new ServletException("Unable to output final image: " + e.getMessage());
        }
      }
    } else {
      // no map file found corresponding to request - 404 error
      enterLog("No map file found which corresponds to request",1);
      // exit
      try {
        response.sendError(404,"No map file found which corresponds to request");
      } catch (IOException e) {
        enterLog("Unable to output 404 error: " + e.getMessage(),1);
        throw new ServletException("Unable to output 404 error: " + e.getMessage());
      }
    }
  }

  private double getOrientation(Integer bearingDeg, Hashtable<String, Float[]> metadata) {
    // calculate direction of north (in radians) from positions of co-ordinates
    Float[] bottomLeft = metadata.get("bottomleft");
    Float[] topRight = metadata.get("topright");
    if (topRight[1].equals(bottomLeft[1])){
      if (bottomLeft[0] > topRight[0]){
        return (Math.PI / 2); // map diagonal is due east so need to add 90 degrees to get direction of bearing on map
      } else {
        // this won't cope sensibly with a 0x0 map, but such a map doesn't have a sensible north direction either...
        return (- Math.PI / 2);
      }
    } else {
      double tangent = (bottomLeft[0] - topRight[0]) / (topRight[1] - bottomLeft[1]);
      double north = Math.atan(tangent);

      // convert bearing to radians...
	  double bearing = Math.PI * bearingDeg / 180;

      return north + bearing;
    }
  }

    // method for creating a log entry (separated out in case we make it more sophisticated later)
  private void enterLog(String entry, int level){
    ServletContext sc = getServletContext();
    String[] code = {"","ERROR","WARN","INFO","DEBUG"};
    if (logLevel >= level) {sc.log("Library Locator: " + code[level] + ": " + entry);}
  }
  
  private float[] getCoordsFromLatLong (float longitude, float latitude, Hashtable<String,Float[]> metadata, Integer width, Integer height){
    Float[] bottomLeft = metadata.get("bottomleft");
    Float[] topRight = metadata.get("topright");
    float returnVal[] = {0,0};
    returnVal[1] = width * ((longitude - bottomLeft[0]) / (topRight[0] - bottomLeft[0]));
    // need to invert the vertical component because pixels counted from the top
    returnVal[0] = height * (1 - ((latitude - bottomLeft[1]) / (topRight[1] - bottomLeft[1])));
    
    return returnVal;
  }
}
